The goal of oolong [1] is to generate and administrate validation tests easily for typical automated content analysis tools such as topic models and dictionary-based tools.
The validation test is called "oolong test" (for reading tea leaves). Creating oolong test for topic models and dictionary-based uses the same function: create_oolong(). The most important parameters are input_model and input_corpus. Setting each of them to NULL generates different tests.
| input_model | input_corpus | output |
|---|---|---|
| Not NULL | NULL | oolong test for validating a topic model with word intrusion test |
| Not NULL | Not NULL | oolong test for validating a topic model with word intrusion test and topic intrusion test |
| NULL | Not NULL | oolong test for creating gold standard |
| NULL | NULL | error |
You can install the development version from GitHub with:
# install.packages("devtools")
devtools::install_github("chainsawriot/oolong")newsgroup_stm is an example topic model trained with the data newsgroup5 using the stm package. Currently, this package supports structural topic models / correlated topic models from stm, Warp LDA models from text2vec and LDA/CTM models from topicmodels.
library(oolong)
library(stm)
#> stm v1.3.5 successfully loaded. See ?stm for help.
#> Papers, resources, and other materials at structuraltopicmodel.com
library(quanteda)
#> Package version: 2.0.0
#> Parallel computing: 2 of 4 threads used.
#> See https://quanteda.io for tutorials and examples.
#>
#> Attaching package: 'quanteda'
#> The following object is masked from 'package:utils':
#>
#> View
library(dplyr)
#>
#> Attaching package: 'dplyr'
#> The following objects are masked from 'package:stats':
#>
#> filter, lag
#> The following objects are masked from 'package:base':
#>
#> intersect, setdiff, setequal, unionnewsgroup_stm
#> A topic model with 10 topics, 4182 documents and a 8920 word dictionary.To create an oolong test, use the function create_oolong_test.
oolong_test <- create_oolong(newsgroup_stm)
oolong_test
#> An oolong test object with k = 10, 0 coded.
#> Use the method $do_word_intrusion_test() to do word intrusion test.
#> Use the method $lock() to finalize this object and see the results.As instructed, use the method $do_word_intrusion_test() to start coding.
oolong_test$do_word_intrusion_test()If you are running this in RStudio, you should see a test screen similar to this:
After the coding, you need to first lock the test. Then, you can look at the model precision by printing the oolong test.
oolong_test$lock()
oolong_test
#> An oolong test object with k = 10, 10 coded.
#> 90% precisionFor example, newsgroup_stm was generated with the corpus newsgroup5$text
library(tibble)
newsgroup5
#> # A tibble: 4,182 x 3
#> text title ntoken
#> <chr> <chr> <int>
#> 1 "\nI agree. So why is Cylink the only (and expensiv… sci.crypt 302
#> 2 "[This is a co-authored report from two of us who we… talk.politics.g… 2900
#> 3 "\njubilee, Pope >Leo the 12th had a medallion cast … soc.religion.ch… 463
#> 4 "\n\nAnd the 'Turkish Karabag' is next. As for 'Cypr… talk.politics.m… 929
#> 5 "The subject says what I would like to do, here are … comp.sys.mac.ha… 218
#> 6 "\n\n\n\nAre you related to 'Arromdian' of ASALA/SDP… talk.politics.m… 920
#> 7 "\n#Rick Anderson replied to my letter with...\n#\n#… talk.religion.m… 1760
#> 8 " ^^^^^^\nNo ar… rec.sport.baseb… 302
#> 9 "\nWhy thanks for your reply to my post. By the way… talk.politics.g… 158
#> 10 " >When some types of client windows are displayed, … comp.windows.x 158
#> # … with 4,172 more rowsCreating the oolong test object with the corpus used for training the topic model will generate topic intrusion test cases.
oolong_test <- create_oolong(newsgroup_stm, newsgroup5$text)
oolong_test
#> An oolong test object with k = 10, 0 coded.
#> Use the method $do_word_intrusion_test() to do word intrusion test.
#> With 41 cases of topic intrusion test. 0 coded.
#> Use the method $do_topic_intrusion_test() to do topic intrusion test.
#> Use the method $lock() to finalize this object and see the results.Similarly, use the $do_topic_intrusion_test to code the test cases, lock the test with $lock() and then you can look at the TLO (topic log odds) value by printing the oolong test.
oolong_test$do_topic_intrusion_test()
oolong_test$lock()oolong_test
#> An oolong test object with k = 10, 10 coded.
#> 100% precision
#> With 41 cases of topic intrusion test. 41 coded.
#> TLO: -0.019The test makes more sense if more than one coder is involved. A suggested workflow is to create the test, then clone the oolong object. Ask multiple coders to do the test(s) and then summarize the results.
Train a topic model.
dfm(newsgroup5$text, tolower = TRUE, stem = TRUE, remove = stopwords('english'), remove_punct = TRUE, remove_numbers = TRUE, remove_symbols = TRUE, remove_hyphens = TRUE) %>% dfm_trim(min_docfreq = 5, max_docfreq = 1000) %>% dfm_select(min_nchar = 3, pattern = "^[a-zA-Z]+$", valuetype = "regex") -> newsgroup5_dfm
docvars(newsgroup5_dfm, "title") <- newsgroup5$title
newsgroup5_dfm %>% convert(to = "stm", omit_empty = FALSE) -> newsgroup5_stm
newsgroup_stm <- stm(newsgroup5_stm$documents, newsgroup5_stm$vocab, data =newsgroup5_stm$meta, K = 10, seed = 42)Create a new oolong object.
oolong_test_rater1 <- create_oolong(newsgroup_stm, newsgroup5$text)Clone the oolong object to be used by other raters.
oolong_test_rater2 <- clone_oolong(oolong_test_rater1)Ask different coders to code each object and then lock the object.
## Let rater 1 do the test.
oolong_test_rater1$do_word_intrusion_test()
oolong_test_rater1$do_topic_intrusion_test()
oolong_test_rater1$lock()
## Let rater 2 do the test.
oolong_test_rater2$do_word_intrusion_test()
oolong_test_rater2$do_topic_intrusion_test()
oolong_test_rater2$lock()Get a summary of the two objects.
summarize_oolong(oolong_test_rater1, oolong_test_rater2)
#> Mean model precision: 0.25
#> Quantiles of model precision: 0.1, 0.175, 0.25, 0.325, 0.4
#> P-value of the model precision (H0: Model precision is not better than random guess): 0.224470246788736
#> Krippendorff's alpha: 0.24
#> K Precision: 0, 0.5, 1, 0, 0, 0, 0, 0.5, 0, 0.5
#> Mean TLO: -2.32
#> Median TLO: -2.06
#> Quantiles of TLO: -9.02803350279604, -3.67855193382253, -2.05776648588221, 0, 0
#> P-Value of the median TLO (H0: Median TLO is not better than random guess): 0.0273333333333333The test for model precision (MP) is based on an one-tailed, one-sample binomial test for each rater. In a multiple-rater situation, the p-values from all raters are combined using the Fisher's method (a.k.a. Fisher's omnibus test).
H0: MP is not better than 1/ n_top_terms
H1: MP is better than 1/ n_top_terms
The test for the median of TLO is based on a permutation test.
H0: Median TLO is not better than random guess.
H1: Median TLO is better than random guess.
One must notice that the two statistical tests are testing the bear minimum. A significant test only indicates the topic model can make the rater(s) perform better than random guess. It is not an indication of good topic interpretability. Also, one should use a very conservative significant level, e.g. α < 0.001.
There is a subtle difference between the support for stm and for text2vec.
newsgroup_warplda is a Warp LDA object trained with the same dataset as the newsgroup_stm
newsgroup_warplda
#> <WarpLDA>
#> Inherits from: <LDA>
#> Public:
#> clone: function (deep = FALSE)
#> components: 0 91 0 0 2 0 260 0 83 27 0 0 26 96 24 0 0 0 37 0 0 10 14 ...
#> fit_transform: function (x, n_iter = 1000, convergence_tol = 0.001, n_check_convergence = 10,
#> get_top_words: function (n = 10, topic_number = 1L:private$n_topics, lambda = 1)
#> initialize: function (n_topics = 10L, doc_topic_prior = 50/n_topics, topic_word_prior = 1/n_topics,
#> plot: function (lambda.step = 0.1, reorder.topics = FALSE, doc_len = private$doc_len,
#> topic_word_distribution: 0 0.000858992995903264 0 0 3.24680595464212e-05 0 0.0022 ...
#> transform: function (x, n_iter = 1000, convergence_tol = 0.001, n_check_convergence = 10,
#> Private:
#> calc_pseudo_loglikelihood: function (ptr = private$ptr)
#> check_convert_input: function (x)
#> components_: 0 91 0 0 2 0 260 0 83 27 0 0 26 96 24 0 0 0 37 0 0 10 14 ...
#> doc_len: 96 1016 142 401 55 364 548 89 48 66 109 61 88 82 163 67 ...
#> doc_topic_distribution: function ()
#> doc_topic_distribution_with_prior: function ()
#> doc_topic_matrix: 0 563 186 343 16 343 1075 7 0 0 236 135 1 0 0 56 0 8 8 4 ...
#> doc_topic_prior: 0.1
#> fit_transform_internal: function (model_ptr, n_iter, convergence_tol, n_check_convergence,
#> get_c_all: function ()
#> get_c_all_local: function ()
#> get_doc_topic_matrix: function (prt, nr)
#> get_topic_word_count: function ()
#> init_model_dtm: function (x, ptr = private$ptr)
#> internal_matrix_formats: list
#> is_initialized: FALSE
#> n_iter_inference: 10
#> n_topics: 10
#> ptr: externalptr
#> reset_c_local: function ()
#> run_iter_doc: function (update_topics = TRUE, ptr = private$ptr)
#> run_iter_word: function (update_topics = TRUE, ptr = private$ptr)
#> seeds: 1281647796.30637 1224775350.35934
#> set_c_all: function (x)
#> set_internal_matrix_formats: function (sparse = NULL, dense = NULL)
#> topic_word_distribution_with_prior: function ()
#> topic_word_prior: 0.01
#> transform_internal: function (x, n_iter = 1000, convergence_tol = 0.001, n_check_convergence = 10,
#> vocabulary: agre expens game town note great boss doubl salari buy b ...All the API endpoints are the same, except the one for the creation of topic intrusion test cases. You must supply also the input_dfm.
### Just word intrusion test.
oolong_test <- create_oolong(newsgroup_warplda)
oolong_test
#> An oolong test object with k = 10, 0 coded.
#> Use the method $do_word_intrusion_test() to do word intrusion test.
#> Use the method $lock() to finalize this object and see the results.newsgroup5_dfm
#> Document-feature matrix of: 4,182 documents, 8,920 features (98.9% sparse) and 1 docvar.
#> features
#> docs agre expens game town note great boss doubl salari buy
#> text1 1 1 1 1 2 2 1 1 1 2
#> text2 1 0 1 0 7 1 0 0 0 4
#> text3 0 0 0 0 0 3 0 0 0 0
#> text4 0 0 0 0 0 1 0 0 0 0
#> text5 0 0 0 0 0 0 0 0 0 0
#> text6 0 0 0 1 0 0 0 0 0 0
#> [ reached max_ndoc ... 4,176 more documents, reached max_nfeat ... 8,910 more features ]oolong_test <- create_oolong(newsgroup_warplda, newsgroup5$text, input_dfm = newsgroup5_dfm)
#> INFO [16:13:48.176] early stopping at 20 iteration
#> Warning in res[setdiff(1:length_test_items, position)] <- sample(good_terms):
#> number of items to replace is not a multiple of replacement length
oolong_test
#> An oolong test object with k = 10, 0 coded.
#> Use the method $do_word_intrusion_test() to do word intrusion test.
#> With 41 cases of topic intrusion test. 0 coded.
#> Use the method $do_topic_intrusion_test() to do topic intrusion test.
#> Use the method $lock() to finalize this object and see the results.trump2k is a dataset of 2,000 tweets from @realdonaldtrump.
tibble(text = trump2k)
#> # A tibble: 2,000 x 1
#> text
#> <chr>
#> 1 "In just out book, Secret Service Agent Gary Byrne doesn't believe that Croo…
#> 2 "Hillary Clinton has announced that she is letting her husband out to campai…
#> 3 "\"@TheBrodyFile: Always great to visit with @TheBrodyFile one-on-one with …
#> 4 "Explain to @brithume and @megynkelly, who know nothing, that I will beat Hi…
#> 5 "Nobody beats me on National Security. \nhttps://t.co/sCrj4Ha1I5"
#> 6 "\"@realbill2016: @realDonaldTrump @Brainykid2010 @shl Trump leading LA Time…
#> 7 "\"@teapartynews: Trump Wins Tea Party Group's 'Nashville Straw Poll' - News…
#> 8 "Big Republican Dinner tonight at Mar-a-Lago in Palm Beach. I will be there!"
#> 9 ".@HillaryClinton loves to lie. America has had enough of the CLINTON'S! It …
#> 10 "\"@brianstoya: @realDonaldTrump For POTUS #2016\""
#> # … with 1,990 more rowsFor example, you are interested in studying the sentiment of these tweets. One can use tools such as AFINN to automatically extract sentiment in these tweets. However, oolong recommends to generate gold standard by human coding first using a subset. By default, oolong selects 1% of the origin corpus as test cases. The parameter construct should be an adjective, e.g. positive, liberal, populistic, etc.
oolong_test <- create_oolong(input_corpus = trump2k, construct = "positive")
oolong_test
#> An oolong test object (gold standard generation) with 20 cases, 0 coded.
#> Use the method $do_gold_standard_test() to generate gold standard.
#> Use the method $lock() to finalize this object and see the results.As instructed, use the method $do_gold_standard_test() to start coding.
oolong_test$do_gold_standard_test()The test screen should be similar to this:
After the coding, you need to first lock the test and then the $turn_gold() method is available.
oolong_test$lock()
oolong_test
#> An oolong test object (gold standard generation) with 20 cases, 20 coded.
#> Use the method $turn_gold() to convert the test results into a quanteda corpus.A locked oolong test can be converted into a quanteda-compatible corpus for further analysis. The corpus contains two docvars, 'answer'.
oolong_test$turn_gold()
#> Corpus consisting of 20 documents and 1 docvar.
#> Access the answer from the coding with quanteda::docvars(obj, 'answer')In this example, we calculate the AFINN score for each tweet using quanteda. The dictionary afinn is bundle with this package.
gold_standard <- oolong_test$turn_gold()
dfm(gold_standard, remove_punct = TRUE) %>% dfm_lookup(afinn) %>% quanteda::convert(to = "data.frame") %>%
mutate(matching_word_valence = (neg5 * -5) + (neg4 * -4) + (neg3 * -3) + (neg2 * -2) + (neg1 * -1)
+ (zero * 0) + (pos1 * 1) + (pos2 * 2) + (pos3 * 3) + (pos4 * 4) + (pos5 * 5),
base = ntoken(gold_standard, remove_punct = TRUE), afinn_score = matching_word_valence / base) %>%
pull(afinn_score) -> all_afinn_score
all_afinn_score
#> text1 text2 text3 text4 text5 text6
#> 0.29411765 -0.09090909 -0.16666667 0.38461538 0.00000000 0.00000000
#> text7 text8 text9 text10 text11 text12
#> 0.16666667 0.38461538 0.00000000 0.38461538 -0.29166667 0.00000000
#> text13 text14 text15 text16 text17 text18
#> 0.50000000 0.07142857 0.00000000 -0.12000000 0.22222222 0.16000000
#> text19 text20
#> 0.33333333 0.38888889Put back the vector of AFINN score into the respective docvars and study the correlation between the gold standard and AFINN.
summarize_oolong(oolong_test, target_value = all_afinn_score)
#> Correlation: 0.71 (p = 0)
#> Effect of content length: -0.285 (p = 0.224)Create an oolong object, clone it for another coder. According to Song et al. (Forthcoming), you should at least draw 1% of your data.
trump <- create_oolong(input_corpus = trump2k, exact_n = 40)
trump2 <- clone_oolong(trump)Instruct two coders to code the tweets and lock the objects.
trump$do_gold_standard_test()
trump2$do_gold_standard_test()
trump$lock()
trump2$lock()Calculate the target value (in this case, the AFINN score) by turning one object into a corpus.
gold_standard <- trump$turn_gold()
dfm(gold_standard, remove_punct = TRUE) %>% dfm_lookup(afinn) %>% quanteda::convert(to = "data.frame") %>%
mutate(matching_word_valence = (neg5 * -5) + (neg4 * -4) + (neg3 * -3) + (neg2 * -2) + (neg1 * -1)
+ (zero * 0) + (pos1 * 1) + (pos2 * 2) + (pos3 * 3) + (pos4 * 4) + (pos5 * 5),
base = ntoken(gold_standard, remove_punct = TRUE), afinn_score = matching_word_valence / base) %>%
pull(afinn_score) -> target_valueSummarize all oolong objects with the target value.
res <- summarize_oolong(trump, trump2, target_value = target_value)Read the results. The diagnostic plot consists of 4 subplots. It is a good idea to read Bland & Altman (1986) on the difference between correlation and agreement.
The textual output contains the Krippendorff's alpha of the codings by your raters. In order to claim validity of your target value, you must first establish the reliability of your gold standard. Song et al. [Forthcoming] suggest Krippendorff's Alpha > 0.7 as an acceptable cut-off.
res
#> Krippendorff's Alpha: 0.741139871924503
#> Correlation: 0.559 (p = 0)
#> Effect of content length: 0.067 (p = 0.683)plot(res)[1] /ˈuːlʊŋ/ 烏龍, literally means "Dark Dragon", is a semi-oxidized tea from Asia. It is very popular in Taiwan, Japan and Hong Kong